Титановый отвар
Почти контрактное программирование в Clojure
21/11/2021

Наконец собрался покурить современные лиспы. Кандидат номер один — Clojure, разумеется: JVM, синтаксис не из каменного века, транзакционная модель памяти, язык активно развивается прямо сейчас, мощное коммьюнити и приличное количество мануалов/бойлерплейтов на все случаи жизни.

Пока идёт хорошо, прям с удовольствием щёлкаю задачки на Exercism. Простенькие, пока привыкаю к синтаксису и возможностям: не так-то просто заставить себя думать иначе после стольких лет питона. И да, скобочки вовсе не страшны, их количество ровно такое же, как в обычных языках. :)

Постоянно всплывают всякие классные особенности языка. Вот прям хочется поделиться одной такой штукой: это уже почти контрактное программирование.

Предположим, у меня есть функция, которая увеличивает число на единицу:

(defn increase-by-one [x]
(inc x))

Но я хочу работать только с числами, большими нуля. Как можно это сделать? Ну вот так, например, по-питоньи, привычно. Проверяем на условие и возвращаем ошибку, если неалё:

(defn increase-by-one [x]
(if (pos? x)
(inc x)
:error))

А что, если ограничений много? Пилить колбасу из and? Как-то некрасиво. Поэтому в Clojure придумали pre и post констрейны. Они задаются в метаданных функции (тоже классная штука, но про неё отдельно рассказывать надо), и в них прописываются предикаты. Если входной параметр (для pre) или результат функции (для post) не удовлетворяет хотя бы одному предикату — будет брошено исключение AssertionError.

Выглядит это так:

(defn increase-by-one [x]
{:pre [(pos? x)]}
(inc x))

Вот более приближенный к жизни пример: получить i-тый элемент из списка. Логично, что i не может быть больше, чем длина списка, и i не может быть отрицательным:

(defn get-item [lst index]
{:pre [(< index (count lst)) (not (neg? index))]}
(nth lst index))

user=> (get-item [1 2 3 4] 1)
2
user=> (get-item [1 2 3 4] 0)
1
user=> (get-item [1 2 3 4] 9)
Execution error (AssertionError) at user/get-item (REPL:1).
Assert failed: (< index (count lst))
user=> (get-item [1 2 3 4] -1)
Execution error (AssertionError) at user/get-item (REPL:1).
Assert failed: (not (neg? index))

Так как Clojure — полноценный функциональный язык, функции в нём суть first-class citizens и их можно передавать как параметры в другие функции. Это открывает возможность комбинировать их друг с другом, создавая цепочки из функций, одна из которых (или несколько!) может быть таким вот валидатором:

(defn time-linearity-checked [f start end]
"Время линейно: проверяем, что start задан не позже end"
{:pre [(< start end)]}
(f start end))

user=> (time-linearity-checked println 36 42)
36 42
nil
user=> (time-linearity-checked println 42 40)
Execution error (AssertionError) at user/time-linearity-checked (REPL:1).
Assert failed: (< start end)

Если мы хотим ограничить функцию ещё и по результатам, нужно скормить нужные предикаты :post. Покажу на примере функции из начала заметки:

(defn increase-by-one [x]
{:pre [(pos? x)]
:post [(< % 50)]}
(inc x))

Результат функции будет подставлен вместо %, и если получилось число, большее 49 — функция бросит исключение.

Полезная штука, в общем. Напоминает зависимые типы в Agda; разве что код сломается на этапе выполнения, а не компиляции (но это общая беда динамически типизированных языков). Удобно строить, например, API и проверять входящие параметры не в самих эндпоинтах, а на подходах к ним, тем самым разделив логику. Что-то похожее есть в FastAPI, например: там корректность переданных в эндпоинты параметров проверяется библиотечкой pydantic.